<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://upyun.luckly-mjw.cn/lib/mux-mp4.js"></script>
</head>

<body>
  <video id="myVideo" src="" controls></video>

  <button id="btnStart">Start</button>
  <button id="btnDownload">Download</button>
  <script>
    // var videoUrl = 'https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/bda11534-a8e7-4366-a39a-a88c48817c27/default.jobtemplate.hls360.m3u8';
    var videoUrl = 'https://m.media-amazon.com/images/S/vse-vms-transcoding-artifact-us-east-1-prod/bda11534-a8e7-4366-a39a-a88c48817c27/default.jobtemplate.hls.m3u8';
    console.log('videoUrl', videoUrl);

    document.querySelector('#btnStart').addEventListener('click', start);
    document.querySelector('#btnDownload').addEventListener('click', download);
    const isLocal = () => location.origin.includes('local');

    const isTest = () => location.origin.includes('test');

    const loadMuxMp4 = (() => {
      let rc = null;
      return () => {
        if (rc) return rc;
        rc = new Promise((resolve, reject) => {
          const s = document.createElement('script');
          s.src = `https://upyun.luckly-mjw.cn/lib/mux-mp4.js`;
          s.onload = () => resolve();
          s.onerror = () => reject();
          document.body.appendChild(s);
        });
        return rc;
      }
    })();

    const loadHls = (() => {
      let rc = null;
      return () => {
        if (rc) return rc;
        rc = new Promise((resolve, reject) => {
          const s = document.createElement('script');
          s.src = `https://cdn.jsdelivr.net/npm/hls.js`;
          s.onload = () => resolve();
          s.onerror = () => reject();
          document.body.appendChild(s);
        });
        return rc;
      }
    })();

    async function concurrentFetch(options) {
      if (!options) {
        return Promise.reject('options is not a object');
      }
      const urls = options.urls;
      const maxNum = options.maxNum || 3;
      const loading = typeof options.updateLoading === 'function'
        ? options.updateLoading
        : (() => { });
      const request = options.request;
      const fetchCount = urls.length;
      if (fetchCount === 0) {
        return Promise.resolve([]);
      }
      if (typeof request !== 'function') {
        return Promise.reject('request is not a function');
      }
      return new Promise((resolve) => {
        const rc = [];
        const retry = new Array(fetchCount).fill(0);
        let currentIndex = 0;
        let completeCount = 0;
        const doRun = async (retryIndex) => {
          const i = retryIndex === undefined ? currentIndex : retryIndex;
          const url = urls[i];
          const end = () => {
            completeCount += 1;
            loading(completeCount);
            doNext();
          };
          try {
            const data = await request(url);
            console.log('concurrentFetch success', i);
            // if (i === 3 && retry[i] <= 2) { throw new Error('My test retry.'); }
            rc[i] = { data, success: true, };
            end();
          } catch (error) {
            console.error('concurrentFetch error:', retry[i], i, url, error);
            if (retry[i] > 3) {
              rc[i] = { data: error, success: false, };
              end();
            } else {
              retry[i] += 1;
              doRun(i);
            }
          }
        };
        const doNext = () => {
          // console.log({ currentIndex, completeCount, fetchCount });
          if (completeCount >= fetchCount) {
            resolve(rc);
            return;
          }
          if (currentIndex >= fetchCount - 1) {
            return;
          }
          currentIndex++;
          doRun(currentIndex);
        }
        const steps = Math.min(fetchCount, maxNum);
        for (let index = 0; index < steps; index++) {
          currentIndex = index;
          doRun(index);
        }
      });
    }

    const m3u8Tool = {
      isM3u8(s) {
        return s ? s.includes('m3u8') : false;
      },
      isM3u8Url(s) {
        return s ? s.includes('.m3u8') : false;
      },

      getBaseUrl(url) {
        return url.substring(0, url.lastIndexOf('/') + 1);
      },

      joinUrl(targetURL, baseUrl) {
        if (targetURL.indexOf('http') === 0) {
          if (location.href.indexOf('https') === 0) {
            return targetURL.replace('http://', 'https://');
          }
          return targetURL;
        }
        if (targetURL[0] === '/') {
          let domain = baseUrl.split('/');
          return domain[0] + '//' + domain[2] + targetURL;
        }
        let domain = baseUrl.split('/');
        domain.pop();
        return domain.join('/') + '/' + targetURL;
      },

      getDuration(s) {
        let n = 0;
        let ls = [];
        s.split('\n').forEach(item => {
          if (item.toUpperCase().indexOf('#EXTINF:') > -1) {
            const v = parseFloat(item.split('#EXTINF:')[1]);
            n += v;
            ls.push(v);
          }
        });
        return {
          duration: n,
          durationList: ls
        };
      },

      handleUrl(url) {
        return isLocal() || isTest()
          ? /datatool/.test(url)
            ? url.replace('https://api.datatool.vip', '')
            : url
          : url;
      },

      getM3u8Urls(s, baseUrl) {
        let ls = s.match(/[^\s\r\n]+\.m3u8(\?[^\s\r\n]*)?/g);
        return ls.map(tsPath => {
          if (tsPath.startsWith('http')) {
            return tsPath;
          }
          return this.joinUrl(tsPath, baseUrl);
        });
      },

      getTsUrls(s, baseUrl) {
        let ls = s.match(/[^\s\r\n]+\.ts(\?[^\s\r\n]*)?/g);
        return ls.map(tsPath => {
          if (tsPath.startsWith('http')) {
            return tsPath;
          }
          return this.joinUrl(tsPath, baseUrl);
        });
      },

      async fetchData(link, noNeedProxy) {
        const back = (r) => {
          const {
            duration,
            durationList
          } = this.getDuration(r.text);
          r.duration = duration;
          r.durationList = durationList;
          r.tsCount = r.tsUrls.length;
          return r;
        }
        if (noNeedProxy) {
          const resp = await fetch(link);
          if (!resp.ok) { throw new Error(resp.statusText); }
          const text = await resp.text();
          if (/.m3u8/ig.test(text)) {
            const m3u8Urls = this.getM3u8Urls(text, this.getBaseUrl(link));
            const m3u8Texts = [];
            const tsUrls = [];
            for (let i = 0; i < m3u8Urls.length; i++) {
              const m3u8Url = m3u8Urls[i];
              const r = await this.fetchData(m3u8Url, noNeedProxy);
              if (r.tsUrls) {
                tsUrls.push(...r.tsUrls);
              }
              if (r.text) {
                m3u8Texts.push(r.text);
              }
            }
            const res = {
              text: m3u8Texts.join('\n'),
              tsUrls,
            };
            return back(res);
          }
          const tsUrls = this.getTsUrls(text, this.getBaseUrl(link));
          const res = {
            text,
            tsUrls,
          };
          return back(res);
        }

        const url = this.handleUrl(link);
        let resp = await fetch(url, {
          'accept-language': 'en',
          credentials: 'include',
        });
        if (!resp.ok) { throw new Error(resp.statusText); }
        resp = await resp.json();
        const { code, message, data } = resp;
        if (code !== 200) { throw { message } }
        const r = JSON.parse(aesDecrypt(data));
        const res = {
          text: r.playlist,
          tsUrls: r.proxy_ts_urls,
        };
        return back(res);
      },

      async fetchTs(link, noNeedProxy) {
        if (noNeedProxy) {
          let resp = await fetch(link);
          if (!resp.ok) { throw new Error(resp.statusText); }
          resp = await resp.arrayBuffer();
          return resp;
        }
        const url = this.handleUrl(link);
        let resp = await fetch(url, {
          'accept-language': 'en',
          credentials: 'include'
        });
        if (!resp.ok) { throw new Error(resp.statusText); }
        resp = await resp.arrayBuffer();
        return resp;
      },

      async getMp4FragList(list, duration) {
        const rc = [];
        for (let i = 0; i < list.length; i++) {
          const ab = list[i];
          const data = await this.convertMp4(ab, i, duration);
          if (!data) {
            throw new Error('convertMp4 error');
          }
          rc.push(data);
        }
        return rc;
      },

      async convertMp4(data, index, duration) {
        await loadMuxMp4();
        return new Promise((resolve, reject) => {
          if (!(data instanceof ArrayBuffer)) {
            reject();
            return;
          }
          const transmuxer = new muxjs.Transmuxer({
            keepOriginalTimestamps: true,
            duration,
          });
          transmuxer.on('data', segment => {
            if (index === 0) {
              let data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
              data.set(segment.initSegment, 0);
              data.set(segment.data, segment.initSegment.byteLength);
              resolve(data.buffer);
            } else {
              resolve(segment.data);
            }
          });
          transmuxer.push(new Uint8Array(data));
          transmuxer.flush();
        });
      },
    };

    async function m3u8ToMp4Blob(videoUrl, options) {
      try {
        if (!options) {
          options = {};
        }
        const noNeedProxy = options.noNeedProxy;
        const {
          text,
          tsUrls,
          tsCount,
          durationList,
        } = await m3u8Tool.fetchData(videoUrl, noNeedProxy);
        console.log({
          text,
          // tsUrls,
          tsCount,
          durationList,
        });
        let completeCount = 0;
        const updateLoading = () => {
          completeCount += 1;
          const f = options.updateLoading;
          if (typeof f === 'function') {
            f(completeCount, tsCount);
          }
        }
        let tsList = await concurrentFetch({
          urls: tsUrls,
          updateLoading,
          request: (link) => m3u8Tool.fetchTs(link, noNeedProxy),
        });
        console.log('tsList.length', tsList.length);
        // console.log('tsList', tsList);
        let newTsList = [];
        let totalDuration = [];
        tsList.forEach((item, index) => {
          if (item.success) {
            newTsList.push(item.data);
            totalDuration.push(durationList[index]);
          }
        });
        totalDuration = totalDuration.reduce((a, b) => a + b, 0);
        const mp4FragList = await m3u8Tool.getMp4FragList(newTsList, totalDuration);
        console.log('mp4FragList.length', mp4FragList.length);
        const fileBits = new Blob(mp4FragList, { type: 'video/mp4' });
        const lastModified = Date.now();
        const fileBlob = new File([fileBits], `${lastModified}.mp4`, {
          type: 'video/mp4',
          lastModified
        });
        const url = URL.createObjectURL(fileBlob);
        console.log('url', url);
        return {
          fileBlob,
          url,
          success: true,
        };
      } catch (error) {
        console.error(error);
        blobPromiseCaches.delete(videoUrl);
        return {
          success: false,
          msg: error.message || 'Failed to obtain the video address. Procedure',
        };
      }
    }
    const blobPromiseCaches = new Map();
    async function getM3u8ToMp4Blob(videoUrl, options) {
      if (blobPromiseCaches.has(videoUrl)) {
        return blobPromiseCaches.get(videoUrl);
      }
      const p = m3u8ToMp4Blob(videoUrl, options);
      blobPromiseCaches.set(videoUrl, p);
      return p;
    }

    async function start() {
      const options = {
        updateLoading: (index, tsCount) => {
          console.log('updateLoading', index, tsCount);
        },
        noNeedProxy: true,
      };
      const mp4Rc = await getM3u8ToMp4Blob(videoUrl, options);
      console.log('mp4Rc', mp4Rc);
      document.getElementById('myVideo').src = mp4Rc.url;
    }

    async function download() {
      const options = {
        updateLoading: (index, tsCount) => {
          console.log('updateLoading', index, tsCount);
        },
        noNeedProxy: true,
      };
      const mp4Rc = await getM3u8ToMp4Blob(videoUrl, options);
      console.log('mp4Rc', mp4Rc);
      const downloadLink = document.createElement('a');
      downloadLink.href = mp4Rc.url;
      downloadLink.download = 'my.mp4';
      downloadLink.click();
    }

  </script>
</body>

</html>